[id].vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. <template>
  2. <div class="admin--page-content">
  3. <div v-if="isLoading" class="admin--loading">데이터를 불러오는 중...</div>
  4. <div v-else class="admin--form">
  5. <!-- 관리자 정보 -->
  6. <table class="admin--form--table">
  7. <colgroup>
  8. <col style="width: 140px;">
  9. <col>
  10. </colgroup>
  11. <tbody>
  12. <tr>
  13. <th><div>아이디</div></th>
  14. <td class="admin--table-title">{{ data.username || "-" }}</td>
  15. </tr>
  16. <tr>
  17. <th><div>이름</div></th>
  18. <td>{{ data.name || "-" }}</td>
  19. </tr>
  20. <tr>
  21. <th><div>이메일</div></th>
  22. <td>{{ data.email || "-" }}</td>
  23. </tr>
  24. <tr>
  25. <th><div>핸드폰</div></th>
  26. <td>{{ data.phone || "-" }}</td>
  27. </tr>
  28. <tr>
  29. <th><div>권한</div></th>
  30. <td>
  31. <span v-if="data.role === 'super_admin'" class="admin--badge admin--badge-super">
  32. 슈퍼 관리자
  33. </span>
  34. <div v-else class="admin--perm-list">
  35. <span v-for="p in data.permissions" :key="p" class="admin--badge admin--badge-perm">
  36. {{ permLabel(p) }}
  37. </span>
  38. <span v-if="!data.permissions.length" class="admin--badge admin--badge-ended">
  39. 권한 없음
  40. </span>
  41. </div>
  42. </td>
  43. </tr>
  44. <tr>
  45. <th><div>상태</div></th>
  46. <td>
  47. <span :class="['admin--badge', getStatusBadgeClass(data.status)]">
  48. {{ getStatusLabel(data.status) }}
  49. </span>
  50. <span v-if="isLocked" class="admin--badge admin--badge-ended ml--16">🔒 잠김 (5회 실패)</span>
  51. </td>
  52. </tr>
  53. <tr>
  54. <th><div>최근 로그인</div></th>
  55. <td>{{ formatDateTime(data.last_login) }}</td>
  56. </tr>
  57. <tr>
  58. <th><div>로그인 실패 횟수</div></th>
  59. <td>
  60. {{ data.login_attempts || 0 }}회
  61. <span v-if="data.last_failed_login" class="ml--16">(최근 실패: {{ formatDateTime(data.last_failed_login) }})</span>
  62. </td>
  63. </tr>
  64. <tr>
  65. <th><div>등록일</div></th>
  66. <td>{{ formatDateTime(data.created_at) }}</td>
  67. </tr>
  68. <tr>
  69. <th><div>최근 수정</div></th>
  70. <td>{{ formatDateTime(data.updated_at) }}</td>
  71. </tr>
  72. </tbody>
  73. </table>
  74. <!-- 버튼 영역 -->
  75. <div class="admin--form-actions">
  76. <button type="button" class="admin--btn" @click="goToList">
  77. ← 목록으로
  78. </button>
  79. <button
  80. v-if="canModify && isLocked"
  81. type="button"
  82. class="admin--btn admin--btn-blue ml--auto"
  83. @click="handleUnlock"
  84. >🔓 잠금 해제</button>
  85. <button
  86. v-if="canModify"
  87. type="button"
  88. class="admin--btn admin--btn-blue-border"
  89. :class="{ 'ml--auto': !isLocked }"
  90. @click="openPasswordModal"
  91. >비밀번호 변경</button>
  92. <button v-if="canModify" type="button" class="admin--btn admin--btn-red-border" @click="handleDelete">
  93. 삭제
  94. </button>
  95. <button v-if="canModify" type="button" class="admin--btn admin--btn-red" @click="goToEdit">
  96. 수정
  97. </button>
  98. </div>
  99. </div>
  100. <!-- 비밀번호 변경 모달 -->
  101. <Teleport to="body">
  102. <div v-if="passwordModal.show" class="admin--modal-overlay" @click.self="closePasswordModal">
  103. <div class="admin--alert-modal admin--form-modal">
  104. <div class="admin--modal-header">
  105. <h4>🔒 비밀번호 변경</h4>
  106. <button class="admin--modal-close" @click="closePasswordModal">×</button>
  107. </div>
  108. <div class="admin--modal-body">
  109. <p class="admin--modal-target">대상 계정 <strong>{{ data.username }}</strong></p>
  110. <div class="admin--form-field">
  111. <label class="admin--form-label">새 비밀번호</label>
  112. <input
  113. v-model="passwordModal.newPassword"
  114. type="password"
  115. class="admin--form-input"
  116. placeholder="8자 이상"
  117. autocomplete="new-password"
  118. />
  119. </div>
  120. <div class="admin--form-field">
  121. <label class="admin--form-label">새 비밀번호 확인</label>
  122. <input
  123. v-model="passwordModal.confirmPassword"
  124. type="password"
  125. class="admin--form-input"
  126. placeholder="다시 입력"
  127. autocomplete="new-password"
  128. @keyup.enter="handleChangePassword"
  129. />
  130. </div>
  131. </div>
  132. <div class="admin--modal-footer">
  133. <button class="admin--btn" @click="closePasswordModal">취소</button>
  134. <button class="admin--btn admin--btn-red ml--auto" :disabled="passwordModal.isSaving" @click="handleChangePassword">
  135. {{ passwordModal.isSaving ? "변경 중..." : "변경하기" }}
  136. </button>
  137. </div>
  138. </div>
  139. </div>
  140. </Teleport>
  141. <!-- 알림 모달 -->
  142. <AdminAlertModal
  143. v-if="alertModal.show"
  144. :title="alertModal.title"
  145. :message="alertModal.message"
  146. :type="alertModal.type"
  147. @confirm="handleAlertConfirm"
  148. @cancel="handleAlertCancel"
  149. @close="closeAlertModal"
  150. />
  151. </div>
  152. </template>
  153. <script setup>
  154. import { ref, computed, onMounted } from "vue";
  155. import { useRoute, useRouter } from "vue-router";
  156. import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
  157. definePageMeta({
  158. layout: "admin",
  159. middleware: ["auth"],
  160. });
  161. const route = useRoute();
  162. const router = useRouter();
  163. const { get, post, del } = useApi();
  164. const { isSuperAdmin } = useAuth();
  165. const adminId = route.params.id;
  166. // 일반 admin은 슈퍼관리자 정보 수정/삭제/비번/잠금 불가
  167. const canModify = computed(() => isSuperAdmin.value || data.value.role !== "super_admin");
  168. const isLoading = ref(true);
  169. const data = ref({
  170. username: "",
  171. name: "",
  172. email: "",
  173. phone: "",
  174. role: "",
  175. status: "",
  176. permissions: [],
  177. login_attempts: 0,
  178. last_failed_login: "",
  179. last_login: "",
  180. created_at: "",
  181. updated_at: "",
  182. });
  183. // 권한 라벨 매핑 (admin.vue menuItems와 동일)
  184. const PERM_LABELS = {
  185. admin: "관리자",
  186. field: "분야/지역",
  187. fishing: "선상/낚시터",
  188. challenge: "챌린지",
  189. quest: "퀘스트",
  190. item: "아이템",
  191. species: "어종",
  192. user: "회원",
  193. };
  194. const permLabel = (id) => PERM_LABELS[id] || id;
  195. const isLocked = computed(() => (data.value.login_attempts || 0) >= 5);
  196. // 알림 모달
  197. const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
  198. const showAlert = (message, title = "알림") => {
  199. alertModal.value = { show: true, title, message, type: "alert", onConfirm: null };
  200. };
  201. const showConfirm = (message, onConfirm, title = "확인") => {
  202. alertModal.value = { show: true, title, message, type: "confirm", onConfirm };
  203. };
  204. const closeAlertModal = () => { alertModal.value.show = false; };
  205. const handleAlertConfirm = () => {
  206. if (alertModal.value.onConfirm) alertModal.value.onConfirm();
  207. closeAlertModal();
  208. };
  209. const handleAlertCancel = () => closeAlertModal();
  210. // 비밀번호 변경 모달
  211. const passwordModal = ref({ show: false, newPassword: "", confirmPassword: "", isSaving: false });
  212. const openPasswordModal = () => {
  213. passwordModal.value = { show: true, newPassword: "", confirmPassword: "", isSaving: false };
  214. };
  215. const closePasswordModal = () => {
  216. passwordModal.value.show = false;
  217. };
  218. // 상세 조회
  219. const loadDetail = async () => {
  220. isLoading.value = true;
  221. const { data: res, error } = await get(`/admin/${adminId}`);
  222. if (error || !res?.success) {
  223. showAlert(error?.message || res?.message || "조회에 실패했습니다.", "오류");
  224. isLoading.value = false;
  225. return;
  226. }
  227. const row = res.data || {};
  228. data.value = {
  229. username: row.username ?? "",
  230. name: row.name ?? "",
  231. email: row.email ?? "",
  232. phone: row.phone ?? "",
  233. role: row.role ?? "",
  234. status: row.status ?? "",
  235. permissions: Array.isArray(row.permissions) ? row.permissions : [],
  236. login_attempts: row.login_attempts ?? 0,
  237. last_failed_login: row.last_failed_login ?? "",
  238. last_login: row.last_login ?? "",
  239. created_at: row.created_at ?? "",
  240. updated_at: row.updated_at ?? "",
  241. };
  242. isLoading.value = false;
  243. };
  244. // 삭제
  245. const handleDelete = () => {
  246. showConfirm(
  247. `'${data.value.username}' 관리자를 삭제하시겠습니까?`,
  248. async () => {
  249. const { data: res, error } = await del(`/admin/${adminId}`);
  250. if (error || !res?.success) {
  251. showAlert(error?.message || res?.message || "삭제에 실패했습니다.", "오류");
  252. } else {
  253. showAlert(res.message || "삭제되었습니다.", "성공");
  254. setTimeout(() => router.push("/site-manager/admin/list"), 800);
  255. }
  256. },
  257. "관리자 삭제"
  258. );
  259. };
  260. // 잠금 해제
  261. const handleUnlock = () => {
  262. showConfirm(
  263. `'${data.value.username}' 계정의 잠금을 해제하시겠습니까?`,
  264. async () => {
  265. const { data: res, error } = await post(`/admin/${adminId}/unlock`, {});
  266. if (error || !res?.success) {
  267. showAlert(error?.message || res?.message || "잠금 해제에 실패했습니다.", "오류");
  268. } else {
  269. showAlert(res.message || "잠금이 해제되었습니다.", "성공");
  270. await loadDetail();
  271. }
  272. },
  273. "잠금 해제"
  274. );
  275. };
  276. // 비밀번호 변경
  277. const handleChangePassword = async () => {
  278. const pw = passwordModal.value.newPassword;
  279. const confirm = passwordModal.value.confirmPassword;
  280. if (!pw || pw.length < 8) {
  281. showAlert("비밀번호는 8자 이상 입력하세요.", "입력 오류");
  282. return;
  283. }
  284. if (pw !== confirm) {
  285. showAlert("비밀번호가 일치하지 않습니다.", "입력 오류");
  286. return;
  287. }
  288. passwordModal.value.isSaving = true;
  289. const { data: res, error } = await post(`/admin/${adminId}/password`, { new_password: pw });
  290. passwordModal.value.isSaving = false;
  291. if (error || !res?.success) {
  292. showAlert(error?.message || res?.message || "비밀번호 변경에 실패했습니다.", "오류");
  293. } else {
  294. closePasswordModal();
  295. showAlert(res.message || "비밀번호가 변경되었습니다.", "성공");
  296. }
  297. };
  298. // 이동
  299. const goToList = () => router.push("/site-manager/admin/list");
  300. const goToEdit = () => router.push(`/site-manager/admin/edit/${adminId}`);
  301. // 라벨 / 뱃지
  302. const getStatusLabel = (status) => {
  303. if (status === "active") return "활성";
  304. if (status === "inactive") return "휴면";
  305. if (status === "suspended") return "정지";
  306. return "-";
  307. };
  308. const getStatusBadgeClass = (status) => {
  309. if (status === "active") return "admin--badge-active";
  310. if (status === "inactive") return "admin--badge-ended";
  311. if (status === "suspended") return "admin--badge-sus";
  312. return "";
  313. };
  314. // 일시 포맷 (24시)
  315. const formatDateTime = (dateString) => {
  316. if (!dateString) return "-";
  317. const date = new Date(dateString.replace(" ", "T"));
  318. if (isNaN(date.getTime())) return dateString;
  319. return date.toLocaleString("ko-KR", {
  320. year: "numeric",
  321. month: "2-digit",
  322. day: "2-digit",
  323. hour: "2-digit",
  324. minute: "2-digit",
  325. hour12: false,
  326. });
  327. };
  328. onMounted(() => {
  329. loadDetail();
  330. });
  331. </script>